Julia is what is described as a functional programming language, meaning that functions are the principal building blocks of a Julia program (as opposed to objects and their instances in OOP).
Introducing functions is the last part we are missing before we can start building fully-fledged applications to solve real world problems. Let's get cracking!
In [1]:
geom_average(a,b) = sqrt(a^2 + b^2)
Out[1]:
In [2]:
geom_average(3,4)
Out[2]:
In [3]:
function breakfast(pancakes, coffee)
println("$coffee cups of coffee and $pancakes pancakes, please.")
end
Out[3]:
In [4]:
breakfast(2,4)
In [9]:
function dinner(sausages, mash)
cost_of_sausages = sausages * 0.85
cost_of_mash = (mash == true ? 0.60 : 0.00) # Ternary operator, condition ? run if true : run if false
cost_of_sausages + cost_of_mash # No formal return statement, just last step in calculation
end
Out[9]:
In [10]:
dinner(2, true) # 2 sausages and yes to mash
Out[10]:
While we haven't told Julia what we exactly want the function to return, it infers that it would probably be the result of the last calculation (cost_of_sausages + cost_of_mash
).
Now imagine that the fictitious canteen, who are so keen on calculating the cost of sausages and mash for dinner, get back to you and want the function to be changed. They are, it turns out, only interested in the cost of sausages.
You could simply put cost_of_sausages
to the very end of the function, before the end
keyword, or you could use the return
keyword, which will tell the function what to give back. Let's redefine dinner(sausages, mash)
to fit the canteen's expectations using the return
keyword:
In [14]:
# Method 1: No return
function dinner(sausages, mash)
cost_of_sausages = sausages * 0.85
cost_of_mash = (mash == true ? 0.60 : 0.00)
cost_of_sausages
end
Out[14]:
In [15]:
dinner(2, true)
Out[15]:
In [16]:
# Method 2: Explicit return
function dinner(sausages, mash)
cost_of_sausages = sausages * 0.85
cost_of_mash = (mash == true ? 0.60 : 0.00)
return cost_of_sausages
end
Out[16]:
In [17]:
dinner(2, true)
Out[17]:
As a matter of style, return
is a good idea to use, even if the function would return the right value. Whoever ends up debugging the script will be grateful you told them what exactly a function ends up returning.
...
('splats')The above simple function had a definite number of arguments that had to be in a particular order.
Arguments where the identity of the particular argument is determined by its position among the arguments are called positional arguments – so in the example above, Julia knew the argument 2
related to sausages
, not mash
, because that's the position in which it was defined.
But what if you don't know how many inputs you are likely to get for a particular function? Let us imagine a function, called shout()
, that shouts the patrons' orders back to the short-order cook. Some customers want a long list of items, others just one or two.
One way to implement this is to expect an array argument:
In [20]:
function shout(food_array)
food_items = join(food_array, ", ", " and ")
# Join food_array elements with a commma, using an optinal final delimiter "and" to joins last two strings
println("Get this guy $food_items\!")
# Print the new food_items string
end
Out[20]:
Invoking this with two arguments, we get
In [21]:
shout(["some pancakes", "sausages with gravy"])
These are returned to the function as a tuple listing each element covered by the splats.
What, however, if someone has more of an appetite? Our shout()
function can accommodate it - the array just needs to get larger. This approach is perfectly viable, but regarded as a bit clumsy. What if I forget the array square brackets, for instance?
In [22]:
shout("pancakes") # Treats each character in the string as an element in an array
Well, that's not quite what I wanted! Fortunately, Julia allows us to have not merely multiple arguments but indeed an indefinite number.
We effect this by suffixing the variable we wish to hold the positional arguments with three full stops ...
, also known as a 'splat':
In [28]:
function shout_mi(foods...)
food_items = join(foods, ", ", " and ")
# Join all of the arguments (up to ...)
println("Get this guy some $food_items\!")
end
Out[28]:
Now our function performs perfectly, whether our customer is ravenous or he just wants some pancakes:
In [29]:
shout_mi("pancakes")
In [30]:
shout_mi("pancakes", "sausages", "gravy", "a milkshake")
What, however, if our customer does not seem to say anything? We would expect this to raise an error... but it doesn't:
In [31]:
shout_mi() # Does not error, `...` takes 0 to N arguments
Therefore, we need be mindful of using a splatted positional argument right in the beginning, since it will accept the input of, well, no input!
A common way to fix this is to require one positional argument, then add a splatted second argument. This way, if the function is called with no arguments at all, it will raise an error.
A better way, perhaps, is to simply test for it ourselves.
The foods
variable is passed on to us as a tuple, which is the default collection type functions return and accept:
In [67]:
function test(argument...)
println("These ", length(argument)," argument(s) in this function are are a Tuple of type ", typeof(argument),".")
end
test()
test(1,2,3)
test("hello","world")
test([1,2,3,4,5])
Therefore, a better way to handle no input, is to simply test for it ourselves:
In [68]:
function bulletproof_shout(foods...)
if length(foods) > 0
println("Get this guy some $(join(foods, ", ", " and "))\!")
else
error("The customer needs to order something!")
end
end
Out[68]:
We use length()
function on this tuple (although do note, we do test explicitly for length(foods) > 0
: a result of zero would not be 'falsey', so testing simply for if length(foods)
would not cut it!).
This will indicate how many elements the tuple has and raise an error if it is zero:
In [63]:
bulletproof_shout("sausages", "pancakes", "gravy")
In [64]:
bulletproof_shout("sausages")
In [65]:
bulletproof_shout()
Finally, the function works.
The last marginal case that you might want to deal with is when the customer's order consists of an empty string ""
or is the wrong type. These are further marginal cases and will not be explored here (although we will be looking at user input in quite a bit of detail in the second part of the book).
The take-away is this - a good function (one you would let your grandmother use) needs to cater for a range of marginal cases and inputs.
Splats are, however, somewhat performance-consuming and are best avoided in code that needs to run fast. In such situations, usability and performance need to be weighed and balanced.
Positional arguments may be 'optional'. This does not mean they are not used - they are optional only from the user's perspective, who will not be required to enter them.
A perhaps better way to put this is that these arguments have default values that take effect if they are not provided at invocation. Consider the following function, which accepts 2D as well as 3D coordinates, and sets 2D coordinates, by default, on the z = 0
plane:
In [72]:
function coords(x, y, z = 0) # z is defaulted to 0 at creation
return(x,y,z)
end
Out[72]:
The result:
In [73]:
coords(1, 6, 7)
Out[73]:
In [74]:
coords(3, 1)
Out[74]:
Setting defaults allows you to prevent the inevitable error that would be triggered if z = 0
were not provided for.
Consider, for instance, what would happen if the value for y
, for which no default value has been set, were to be missing:
In [76]:
coords(3) # Missing y value
Julia is telling us, in its somewhat odd grammar, that the function coords()
is not defined for a single input. It requires at least two arguments. This is the coords(::Any, !Matched::Any)
bit.
The second line refers to the fact that it can also take on an additional third argument of type Any
, which in this case happens to be the z variable we defaulted.
We can set this default (optional) variable as we see fit:
In [79]:
coords(3, 4, π) # Overwrite z
Out[79]:
The drawback of positional arguments is that getting the order right can be an inconvenience.
Wouldn't it be much easier, not the least from a documentation perspective, if we were allowed to give arguments names and use these names at invocation?
With Julia, you can do so at your heart's content, as long as you:
;
, as in this snippet:
In [81]:
function buzzphrase(verb, adjective; subject="defence", goal="world peace")
println("$(verb)ing $adjective $subject for $goal.")
end
Out[81]:
In this function, verb
and adjective
are necessary positional arguments:
In [88]:
buzzphrase("defend", "rad") # delivers both requires arguments
In [87]:
buzzphrase("defend") # errors because insufficient arguments
However, you can use the keyword argument syntax for subject
and goal
. As you can see, both have defined default values which is necessary for keyword arguments in Julia.
Therefore:
In [82]:
buzzphrase("leverag", "effective", subject="best practices", goal="increased margins") # subject, then goal keywords
is equivalent to:
In [83]:
buzzphrase("leverag", "effective", goal="increased margins", subject="best practices") # goal then subject keywords
The order of keyword arguments is irrelevant, anything after the ;
syntax in the function argument definition is not positionl.
and yield the same results.
->
Sometimes, you're in a hurry and need a throwaway function.
Whether it's for map
ping an Array or comparing values in a sort, sometimes you don't want to define a function. A number of languages refer to these as anonymous functions, because they do not have a defined name, or reserve a lambda
keyword for this, harkening back to Alonzo Church's 'lambda calculus' well before the advent of modern computers.
Julia has a stylised arrow ->
, leading to the name stabby lambda for such functions.
Assume you want to map
the array of all primes under 10 [2,3,5,7]
to a function f
so that f(x) = 2x^3 + x^2 - 2x + 4
.
In case you're unfamiliar with map()
functions, here's the elevator pitch: map functions take a function and an iterable and return an iterable of equal length, each element of which will be the result of feeding an element of the original iterable into the function. In this way, we "map" the initial values to final values via a transformative function.
Here's a post of it being used in Python, and a little visual:
In Julia, map()
takes two arguments - a function and an iterable.
For the former, you can use a function defined in advance or use the stabby lambda notation that is the subject of this section.
The mapping function would be written in the "stabby" / lambda notation as
In [89]:
x -> 2x^3 + x^2 - 2x + 4
Out[89]:
somewhat similar to the maplet notation in mathematics. Thus, we would use the map
function as follows:
In [90]:
map(x -> 2x^3 + x^2 - 2x + 4, [2, 3, 5, 7]) # maps anonymous function against array
Out[90]:
The stabby lambda is a little controversial, being even discouraged where it serves as a mere wrapper by the official Julia Style Guide, for the reason that such functions are impossible to unit test and can make code confusing.
In general, the advice that is often given to, and by, Python programmers about lambda
s in Python holds for their stabby Julia equivalents: a stabby lambda should be obviously and unambiguously true, that is, it should be evident at first glance
In other words, consider a stabby lambda a sort of 'special pleading' - you're arguing that the function is so trivially true, defining it in a long and extensive way would benefit the code less than what is gained by the brevity of the stabby lambda syntax.
In [91]:
map([2, 3, 5, 7]) do x
2x^3 + x^2 - 2x + 4
end
Out[91]:
The do
block is a bit of syntactic sugar that helps us avoid unduly long stabby lambdas, as well as do slightly more complex things that the stabby lambda's restricted format might not allow for, such as more complex testing than a stabby lambda coupled with a ternary operator chain would allow:
In [98]:
# Do block with an unambigous and clear control flow
map([2, 3, 5, 7]) do x
if mod(x, 3) == 0
x^2 + 2x - 4
elseif mod(x, 3) == 1
2x^3 + x^2 - 2x + 4
else
2x-4
end
end
Out[98]:
versus:
In [99]:
# Lambda with a ternary chain that is much more ambigious
map(x -> mod(x, 3) == 0 ? x^2 + 2x - 4 : mod(x, 3) == 1 ? 2x^3 + x^2 - 2x + 4 : 2x-4, [2, 3, 5, 7])
Out[99]:
As a side note, a ternary operators can be chained, and of the form:
<condition> ? <run anonymous lambda if true> :
<else if condition> ? <run anonymous lambda if true> :
<another else if condition? <run anonymous lambda if true> :
<else run anonymous lambda if all earlier conditions are false> )
In [100]:
function squares(x, y)
return x^2, y^2
end
Out[100]:
In [101]:
squares(2,5)
Out[101]:
In [102]:
typeof(squares(2,5))
Out[102]:
In [103]:
As we can see, the function returned two values of type `Int64`, in a tuple.
For various reasons, you may prefer defining your own type to return, such as a composite type - this is up to you and Julia gives you considerable freedom in doing so.
Scope in function evaluation refers to the availability of variables within or outside a function. Much of what has been said about scope in blocks in general applies here, but function evaluation has some peculiar quirks that are worth mentioning.
Julia implements lexical scoping, that is, the scope of a function is inherited not from its caller but its definition. Consider the following:
In [146]:
function foo(x)
println(x)
end
Out[146]:
In [113]:
function bar()
x = 2
foo() # Inherits the function foo, but with no arguments; does not pick up the x = 2 by default
end
Out[113]:
In [114]:
bar() # This will error because x is not defined for the inside function
In [115]:
foo() # The original error that was raised in bar()
In [116]:
foo(2) # Does work if you define it in the argument, not the scope
This is not unexpected, since the assignment of x
to 2 is 'not visible' to the function foo
when it's called.
In other words, the assignment of x
is outside the scope of the function. Therefore, it does not see
the variable's definition and this yields an undefined variable error.
In [122]:
x = 2
Out[122]:
In [123]:
foo() # Does not an argument, because x is already defined globally
In [ ]:
@time functionwithnoglobal(x)
While this is very helpful, global variables incur an immense performance penalty:
In [150]:
function yesglobal(x) # should inherit the global variable x = 2
x ^ 2
end
Out[150]:
In [143]:
function noglobal(y) # will need a locally designated variable y
y ^ 2
end
Out[143]:
In [153]:
@time yesglobal(x)
Out[153]:
In [154]:
@time noglobal(2)
Out[154]:
Therefore, their use is generally discouraged unless absolutely necessary.
In general, the idea of a higher order function serves to distinguish functions that accept a function as an argument from other functions, sometimes referred to as first-order functions.
In functional programming, higher order functions are much more important than in OOP or other paradigms, and indeed even if you return to your OOP roots, an understanding of higher order functions will help you enormously in dealing with the implementations of higher order functions in your language of choice: since most higher-order functions are so useful for munging data, most programming languages do have implementations of map()
, sort()
and other archetypal higher order functions.
We have already introduced map()
, a typical higher-order function, above. While higher-order functions appear to be somewhat complex, they are actually easier than they seem.
A function is an object like any other, and so can be fed into another function as an argument. You will not, generally, need to do anything special for your function to accept a function as an argument, except make sure you are calling the function provided to you.
In [155]:
"""
Greets `x` using a string.
"""
function greet(x)
str = x()
println("Hello, $str\!")
end
Out[155]:
In [157]:
"""
Returns the string "world"
"""
function tell_me_where_I_live()
return("world")
end
Out[157]:
In [158]:
greet(tell_me_where_I_live) # Take the string returned by the inner function, and pass it to the outer function
Quite importantly, when you are passing a function to another function as an argument, you are not passing a call, you're passing the function object - so don't forget to skip the parentheses ()
!
In [160]:
greet(tell_me_where_I_live()) # This won't work, since the () calls the function on something
In [161]:
"""
Calls the function x on arguments y and z.
"""
function oper(x, y, z)
return x(y, z)
end
Out[161]:
In [163]:
oper(+, π, e) # Adds π and e
Out[163]:
In [164]:
oper(-, π, e) # Subtracts π and e
Out[164]:
In this case, the operator +
was fed into our function (which did nothing but execute the operator fed in as x
on y
and z
).
In [166]:
"""
Creates an an anonymous expontential function of base `exponent`.
"""
function create_exponential_function(exponent)
exp_func = function(x) # anonymous function defintion
return x^exponent # that returns a function
end
return exp_func # return this new anonymous function
end
Out[166]:
In [169]:
power_of_five = create_exponential_function(5) # Generate an anonymous function and name it
Out[169]:
In [170]:
power_of_five(5) # Use the anonymous function we've generated
Out[170]:
The function above can be written more concisely with the stabby lambda syntax we encountered earlier:
In [172]:
function create_exponential_function(exponent)
y -> y^exponent # Create an anonymous function of this form
end
Out[172]:
Some languages, including some functional languages, support a feature called currying, named not after the Indian spice but after logician Haskell Curry (namesake of the Haskell
language).
A curried function is one that has multiple arguments. If it is provided with values for all of them, it returns a value. If it is provided with only part of them, it returns a sort of unfinished function that takes the missing values as arguments.
Currying was proposed for Julia in 2012, but voted down, not least because it would have been difficult to accommodate within multiple dispatch.
When you call a function on a number of arguments, Julia needs to decide how exactly that function makes sense for those arguments.
n this sense, functions are not so much names for individual functions but for bunches of conceptually similar functions, with Julia deciding which particular one to call.
Consider the *
operator (which, like all operators, is a function):
In [174]:
π * e
Out[174]:
In [175]:
"sausages " * "mash"
Out[175]:
As the example above shows, the *
function can take various types, and it has various actions defined for each - for numeric types, this involves multiplication, while for strings, *
means concatenation.
The feature of Julia that allows the call of the right implementation of a function based on arguments is called multiple dispatch, and the implementations are referred to as methods.
Each function may have a number of methods defined for various data types, and it may have no methods at all defined for some. Finally, the error message we get when we use the 'wrong' type of input starts to make sense:
In [177]:
2 * "sausage" # There is no method for what to return when you do Int64 * ASCIIString
What Julia is referring to in this instance is that *
is not defined for one Int64
and one ASCIIString
operator.
In other words, the function *
has no method defined that would take these two particular kinds, after which it then recommends various options (some fairly unexpected, for instance, ::Number
* ::Bool
is perfectly valid – it multiplies the ::Number
by 1 if the ::Bool
is true
and 0 if it is false
).
In [185]:
"""
Adds together two values of type Number.
"""
function merge_together(a::Number, b::Number)
a + b
end
Out[185]:
This is great. It does a great job at adding up numbers:
In [179]:
merge_together(2, π)
Out[179]:
It's less adept at doing the string concatenation part we need it to do:
In [180]:
merge_together("Sausages with", " mash")
Therefore, we will need to define a method for merge_together()
that will accept ASCIIString
arguments.
When Julia tells us a method is missing, it will give us the concrete data type of the argument we have entered. This is useful, but try to resist the temptation to define merge_together
for ::ASCIIString
.
In general, if your use case relates not to the concrete type but to the broader, abstract type (such as ours, where our use case is really all strings, not just ASCIIString
), it's good practice to use the broadest abstract type that will include only the data types that you need.
In this case, it is not ASCIIString
but its abstract ancestor, AbstractString
(in case you forgot your handy inheritance dendrogram, you can look at the supertype of any type by using super(ASCIIString)
:
In [184]:
super(super(ASCIIString)) # The lever right before the `Any` type is abstract enough
Out[184]:
with the name of the type you're interested in). Let's define merge_together
for two ::AbstractString
objects:
In [186]:
"""
Adds another method for adding together two values of type AbstractString.
"""
function merge_together(a::AbstractString, b::AbstractString)
a * b
end
Out[186]:
That's it, folks! Julia helpfully tells us that merge_together
now has two methods. Using methods(merge_together)
, we can list these:
In [187]:
methods(merge_together) # What types has this function been defined for
Out[187]:
Let's give the second one, for strings, a try:
In [188]:
merge_together("Sausages with", " mash")
Out[188]:
It works!
In general, when creating a function, you need to be circumspect as to what you want to use it for and what it needs to be able to deal with.
There is no need for a function to have methods for all data types. So far, we have generally not defined the data types of arguments. This is a bad practice, and when you are building functions, you should always think of yourself as building methods at the same time, and define the types you want your function to accept.
In [189]:
function f(x)
return x
end
Out[189]:
In [190]:
function f(x::Int)
return x^2
end
Out[190]:
The first definition, lacking a type restriction, is deemed by Julia to accept inputs of type Any
- that is, any type.
The second method, however, only takes inputs of type Int
. As such, it is more specific (or, if you please, 'further downstream on the type dendrogram').
The result is that when you call f(2)
, the second, more specific method will be called, even if technically, the argument 2
would be acceptable for both. This is a sensible approach, since the broader the type, the more likely that the method is intended to be a 'catch-all' to mop up cases that have not been caught by any of the subtypes.
However, for functions with multiple arguments, it is possible that there is no unique method that is more unambiguous than the others. Consider the following:
In [191]:
function g(x::Int, y)
return 2x^2 - 2y
end
Out[191]:
In [192]:
function g(x, y::Int)
return 2x - 2y^2
end
Out[192]:
Which of these functions is 'more definite' when called as, say, g(6, 8)
? The answer is 'neither', and Julia says so via an error when when declaring the second method:
WARNING: New definition
g(Any, Int64) at In[192]:2
is ambiguous with:
g(Int64, Any) at In[191]:2.
To fix, define
g(Int64, Int64)
before the new definition.
It also helpfully proposes a method g(x::Int64, y::Int64)
that is more specific than either of the previously defined methods, and as such capable of dealing with the indefinite middle.
In [193]:
function g(x::Int, y::Int)
return x^2 - y^2
end
Out[193]:
A parametric method, similar to parametric types, is one in which a logical relationship is asserted between types, rather than an actual type name. You may think of parameters as 'variables' for type assertions.
The parameter - by convention, but not by necessity, T
for type - is enclosed in curly braces {}
and interposed between the function name and its arguments:
In [198]:
function identical_types{T}(x::T, y::T)
end
Out[198]:
In [199]:
methods(identical_types)
Out[199]:
This function would accept arguments of the same type, regardless of what that type is. You can restrict the possible values T
might take based on type hierarchy:
In [200]:
function identical_numbers{T<:Number}(x::T, y::T)
end
Out[200]:
In [202]:
methods(identical_numbers)
Out[202]:
This function allows for any inputs that are both identical and descendants of the Number
supertype.
This in contrast to the case that accepts only:
In [205]:
function divergent_numbers(x::Number, y::Number)
end
Out[205]:
In [204]:
methods(divergent_numbers)
Out[204]:
which accepts inputs that are descendants of the Number
supertype, regardless of whether their type matches or not.
In [206]:
+
Out[206]:
You can inspect methods available under a function by using the method()
command and passing the function or operator as argument:
In [207]:
methods(+)
Out[207]:
In this chapter, we have learned how to deal with functions in Julia. We have learnt about their input and return syntax (Tuples), defining optional (x = value
) and keyword arguments (;keyword = "value"
), defining anonymous 'stabby' lambda funtions with ->
and do
blocks, exploring function scope (global and local), the notion of higher order functions which take functions themselves as an argument (map
, reduce
, etc.), the underlying notion of multiple dispatch and types that handle function methods, defining parametric functions for sets of Types ({T<:Type}
), and how to inspect function methods with methods(function)
.